iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0

https://ithelp.ithome.com.tw/upload/images/20250831/20118113BLkCodRJyL.png
今天到了系列文的第二階段,fp-ts的函式庫粉墨登!

fp-ts

設計緣由

fp-ts的作者Giulio Canti利用Typescritp靜態型別系統,以及泛型、條件型別等進階型別功能儘可能的模擬Haskell和PureScript等專為函數式程式設計語言常需要的型別類型,如Option、Either、IO和Task等型別建構子(型別容器)

設計決策

fp-ts的一個重要設計決策是:「fp-ts是一個函式庫而非一個框架」,因為在 TypeScript 中,若使用 class 或 prototype,會遇到一些型別推斷限制或繼承問題,並且為了建構Monad,Applicative等抽象型別,並實現 Type Class 模型(Higher-Kinded Types),因此採用了「模組化設計」(Module-based Design),而非基於 Object.prototype 的物件導向設計。

如此設計的優點可以避免造成全域污染,並具有較佳的擴展性,只需引入所需要的模組,針對某個型別進行擴充,無需動到核心結構。也由於採用「模組化設計」,使用者可漸近式採用fp-ts模組。你可以只使用Option和Either模組來進行錯誤處理,而無需一開始就學習所有的模組使用,由於各型別模組的高度抽象性,學會一個模組的使用便能夠將其觀成擴展至其它模組,可以減少進入函數式程式設計的痛苦。

Typescript的模組主要分成下列幾類:

  1. 資料型別模組:
    這些模組提供型別容器,這些型別容器是型別函數,必須給予一個泛型的型別參數才能成為一個型別實例,如Array、Option、Either、Task、IO、Reader、State…等模組。這些模組通常有針對該容器設計的map、ap、flatMap(chain)…等函數,讓我們可以讓函數在不同的容器空間中轉換。
  2. 型別類別(Type Class)模組:
    這是為了實現(Higher-Kinded Types)觀成而設計的一些介面,如果具備某介面的資料型別就稱為某種型別類別,例如提供map函數且滿足特定條件的資料型別就稱為Functor。這些型別類別模組提供各種型別類別抽象操作介面(Model),以抽象介面相關的工具函數(Utils)和其它相關函數。如Magma、Monoid、Functor、Monade…等模組。
  3. 類型工具模組:
    由於Typescript不支援 HKT(Higher-Kinded Types)原生語法,fp-ts有模擬 Higher-Kinded Types的HKT模組,另外還有Kind和URIToKind函數讓我們自訂類型類別(Type Class)

這些函數的名詞對於不熟悉函數式程式設計的人感到陌生而害怕,因此我們會透過剩下的系列文章,有系統的介紹這些概念和模組,今天就從大家最熟悉的Array模組開始。

Array模組

Array這個資料型別就是我們熟悉的Typescript陣列型別,Array是一個nondeterministic的型別容器(Container),也就是容器內的具有的值的數量是未定的。那我們為什麼要使用這個模組內的函數,而不使用Typescript原來提供的函數呢?

首先,fp-ts所提供的函數都是不可變性(Immutable),typescript所提供的函數有些會改變輸入的陣列,會造成副作用(Side effect),Array 模組中的函數都不會改變原始陣列,而是返回一個新的陣列。

其次,Array 模組中的陣列資料處理函數的參數順序都是資料轉換函數先,陣列後的順序,如此可以方便pipe和flow來合成各個處理陣列資料的函數,形成一個陣列資料處理管道,讓我們更容易的「接管」。

最後,Array 模組中的函數都具備型別安全的特性,對於一些可能回傳null或undefined的函數都會以Option型別封裝我們的資料,明天我們將會介紹Option這個型別容器。

陣列建構函數

這個模組提供了3個Constructors,分別為 makeBy、of 和 replicate,他們的使用方法分如下:

import { makeBy, of, replicate } from 'fp-ts/Array'
const double = (i: number): number => i * 2
const arrayA = makeBy(5, double) // [1, 4, 9, 16, 25]
const arrayB = of('a') // ['a']
const arrayC = replicate(4, 'a') // ['a', 'a', 'a', 'a']
const arrayD =  ['a', 'b', 'c', 'd']

當然你也可以用我們一般Typescript定義陣列的方法,如上例的arrayD,這邊特別值得一提的是 of 這個陣列建構函數,所有容器幾乎都會有這個建構函數,of函數會輸出一個型別容器的資料型別。如果我們匯入的容器模組比較多時,每個模組都有of這個constructor,會有撞名的問題,我們會採用下列的習慣匯入。

Typescript並不是專門的函數式程式設計語言,對High Kinded Types型別推論支援不夠,因此無法共用一個map名稱,必須用A.map、O.map…方式以區別不同型別的map,在Haskell上,便沒有這種困擾。

import * as A from 'fp-ts/Array'
const arrayB = A.of('a') // ['a']

為什麼會用of這個名字呢?如果你還記得我們在介紹數學函數值的念法時,f(3)讀作 f of 3,這裏A.of('a')也是將3對應到['a']的概念。

map、filter、reducer

map、filter、reduce三個是Array模組最重要的函數,功能和Array內建的方法功能一樣,不同的地方在於,模組裏的三個函數是以curry的形式呈現,接收參數的順序則是先函數再陣列,這樣設的好處是方便用pipe或flow合成我們的陣列資料處理函數。

import { map, filter, reduce } from 'fp-ts/Array'
import { pipe } from 'fp-ts/function'

const f = (n: number) => n * 2
const mapResult = pipe([1, 2, 3], map(f)) // [2, 4, 6]

const filterResult = filter((x: number) => x > 0)([-3, 1, -2, 5]) // [1, 5]

const reduceResult = reduce(5, (acc: number, cur: number) => acc * cur)([2, 3]) // 30

這三個函數型別註解分別如下:

const map: <A, B>(f: (a: A) => B) => (fa: A[]) => B[]
const filter: <A>(predicate: Predicate<A>): (as: A[]) => A[]
const reduce: <A, B>(b: B, f: (b: B, a: A) => B) => (fa: A[]) => B

Predicate<A>型別是A -> Boolean的函數型別

Functor

一個具有map的函數的資料型別我們稱之為Functor,map函數除了為宣告式程式設計風格之外,更重要的是為了「接管」方便。函數式程式設計最大的特色便是函數之間的合成,每個函數都像是水管一樣,函數合成便像是將不同的水管接在一起,讓資料順利的流通,所以函數式程式設計不使用一般控制流程的語法,而是專注在函數之間的合成。
fp-ts將下列型別簽署的函數稱為Pure program
g = (b: B) => C

假設F是一個型別建構子(type constructor,Array或[]就是type constructor),型別建構子 F 本身不是具體的型別實例,例如Array本身並不能算是一個型別,它必須有一個型別參數(泛型),像Array<number>才是一個具體型別實例。下列型別簽署的函數稱為Effectful program

f = (a: A) => F<B> // 輸出不是一般型別,而是由型別建構子建構的型別

我們無法直接複合這兩個函數g.f,因為g的輸出型別為F<B>,f的輸入型別為B,兩者不同,所以無法滿足法函數複合的條件,因此需要map這個函數,將f提升到F建構的空間,map(g)的型別簽署便會是
map(g): (fb: F<B>) => F<C>
如此的話,map(g)和f便可以進行複合,如下圖所示。

import { pipe } from 'fp-ts/function'
import { map, of } from 'fp-ts/Array'
const increment = (n: number): number => n + 1
const result = pipe(2, of, map(increment))

上面程式的流程是將2這個數字,經過of這個函數放入陣列中得到[2],而我們將increment這個number對應到number的函數map到陣列的空間,如此便可進行「接管」的工作。當然,上面這個例子純綷是為了說明Functor的概念所設計,將來再把這個概念抽象化用在其它的Functor上面。

我們小時候學習數學的過程中常常會利用這種抽象化過程來幫助數學概念建立,我們舉一個例子,一瓶水的3/4是8公升,求一瓶水是多少公升?很多學生會無法記得要用除法還是乘法,但是如果我們將題目改成一瓶水的2倍是8公升,求一瓶水是多少公升?學生會馬上計算8除以2,抽象化能力高的學生,會將這概念移轉到前面的題目,計算8除以3/4。當然,要得到最後的答案必須計算8乘以4/3, 至於為什麼要這麼算,很多人是說不上所以然來。
又例如計算函數f(x) = (x + 1)(x + 2)(x + 3),不論我們要求f(3)或f(3/4),甚或f(2+3i),我們一開始都可以展開,最後再將值代入。

抽象化應用在我們的型別容器,我們的map的Hindley Milner型別簽名可以這樣寫

map :: ( Functor f ) => (a -> b) -> f a -> f b

=>符號之前是型別限制,也就是說 f 必須是一個Functor, =>符號之後是函數的型別簽名,在typescript來說,如果 f 是Array來說,這個型別簽名就代表給我一個型別 A 到型別 B 的函數,map 會回傳一個型別Array<A>到型別Array<B>的函數。明天開始我們會介紹Option, Either, IO , Task…等Functor,對應於Option的map就代表說給我一個型別 A 到型別 B 的函數,map 會回傳一個型別Option<A>到型別Option<B>的函數。

Functor除了要有map這個函數之外,還要滿足二個條件,其中最重要的一個便是
map(g ∘ f) = map(g) ∘ map(f)
也就是兩個在原本空間的函數先複合然後再「提升」到F空間的結果等同於先將兩個函數「提升」到F空間然後複合.

import { flow, pipe } from 'fp-ts/function'
import { map } from 'fp-ts/Array'

const double = (n: number): number => n * 2

// iterates array twice
const doubleAndIncrement1 = pipe([1, 2, 3], map(double), map(increment)) // => [ 3, 5, 7 ]

// single iteration
const doubleAndIncrement2 = pipe([1, 2, 3], map(flow(double, increment))) // => [ 3, 5, 7 ]

今日小結

今天介紹了fp-ts的設計理念和設計方式,還有它的模組大致如何分類。Array做為我們最為熟悉和常用型別建構子,當然率先登場,map,filter和reduce三個函數也在重新封裝後能夠在pipe中順利的處理資料。

我們也說明了Functor-這個函數式程式設計最重要的名詞的抽象意義和它的Hindley Milner型別簽名,明天開始我們就會開始介紹其它的Functor,今天就分享到這邊,明天再見。


上一篇
Day 10. FP程式範例 - 淺嚐密碼學(Cipher)
下一篇
Day 12. 錯誤處理 - Option & Either
系列文
數學老師學函數式程式設計 - 以fp-ts啟航20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言